
系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Design(回頭對稿)/ Claude Code
今日進度:首頁 → 對白(打字機)→ 三選項(薪火不足會灰掉)→ 自動導向戰鬥 → 結局頁,全部接上 Go 的劇情 API
Day 2 用 Claude Design 畫了四張 artboard,其中 docs/design/dialogue.{png,html} 是對話框;Day 7 的實驗證明,HTML 加上 docs/design/README.md 的意圖說明餵給 Claude Code(方法 C),產出的 DialogueBox.vue 91% 可直接用、只改 6 行、花 8.5 分鐘(只給 PNG 的方法 A 是 58%、只給 HTML 的方法 B 是 79%)。那個元件 Day 7 後就躺在 components/ 裡沒接資料,像蓋好但沒人住的房子。
今天延續 Day 7 的 components/DialogueBox.vue,接上 store 與打字機效果,再把 Day 4 定的 graph.json 與 Day 13 的劇情 API 串成一條可以走到結局的路。劇情是這款遊戲「有靈魂」的那一半,今天是它第一次在畫面上開口。
Day 13 的 API 只有三個劇情端點:GET /story、POST /story/choose、POST /battles。沒有獨立的「下一句對白」端點,但契約寫得很清楚:dialogue 與 route 節點也走 POST /story/choose,choiceIndex 會被後端忽略。所以前端的 advance() 就是 choose(0)——這不是我的設計,是 Day 13 契約的結果。
我一度想省掉這個往返:前端有共用的 graph.json(monorepo 同一檔案,Vite 用 @data alias import),可以自己沿 next 把對白走完,只在決策點打 API。Claude Code 反問一句:「後端的當前節點會停在哪裡?」答案是停在對白,接下來 choose 會拿到 409 INVALID_NODE。契約說狀態在後端,就不該有第二份狀態在前端。多一次往返代價是每句對白 30–60ms,玩家察覺不到;換來的是每推進一步多一筆 SK = EVENT#<時間戳> 的稽核 item(PK 是 SAVE#<id>),Day 25 的 QA 與 Day 29 統計都靠它。
錯誤碼也是契約的一部分:SAVE_NOT_FOUND 404、INVALID_NODE 409、INVALID_INPUT 422、BAD_ID / BAD_JSON 400,格式一律 { error: { code, message } }。api/client.ts 今天先寫基本版:apiGet / apiPost 兩個函式、讀 import.meta.env.VITE_API_BASE——本機 .env.development 指向 http://localhost:8080,正式環境是空字串,走同源 /api/*(CloudFront 代理到 API Gateway);把錯誤轉成帶 status 與 code 的 ApiError,UI 只看 code。重試、token header、逾時等 Day 22 串接時再強化。
store 只有兩份資料:後端回傳的當前 node(含 id)與 state(flags、ember、cleared),前端只讀不改。為什麼 flags 與 ember 不在前端算?因為 Day 5 的 ADR 把「後端負責劇情狀態與結果驗證」定成邊界:前端可以被改(開 DevTools 改 store 只要一秒),後端寫下的 SK = EVENT#… 稽核 item 才是存檔與三結局 QA 的依據。前端拿到 state 只是為了顯示與灰化,改了也只是騙自己。
// frontend/src/stores/story.ts(節錄;loading 的 try/finally 省略)
const node = ref<StoryNodeWithId | null>(null) // 後端當前節點
const state = ref<StoryState | null>(null) // flags / ember / cleared
function apply(res: StoryResponse): void { node.value = res.node; state.value = res.state }
async function start(playerName: string): Promise<void> {
const created = await apiPost<{ save: { id: string } }>('/api/v1/saves', { playerName })
saveId.value = created.save.id
apply(await apiGet<StoryResponse>(path('/story')))
}
async function choose(choiceIndex: number): Promise<void> {
apply(await apiPost<StoryResponse>(path('/story/choose'), { choiceIndex }))
}
/** dialogue / route 節點前進:Day 13 契約沒有獨立的 advance 端點 */
const advance = (): Promise<void> => choose(0)
reportBattle(report) 與 choose 長得一樣、只是打 /battles;canChoose(c) 比 state.ember 與 c.requires.ember。四個 action 對應 Day 13 契約表的三個端點,三個回應都經過同一個 apply,所以之後 Day 22 加重試、Day 27 加 token,都只碰 client.ts,store 不用動。StoryNode 是五種節點的 discriminated union(dialogue / choice / battle / route / ending),template 裡用 node.type narrow,少寫任何一種 vue-tsc 都會抱怨——route 就是這樣被我漏掉又被抓回來的,Day 21 會講。battle 節點可能帶 variant(vigil/blaze/truce),前端原樣帶進 BattleView,不自己重算路線——路線判斷永遠只在後端的 Route() 做一次。
踩雷一則:ChoiceList 需要 Choice 型別,我原想從 @data/story/graph.json 的 import 推導。Vite 給的是 JSON 字面型別,nodes 裡每個節點被推成形狀各異的物件,type 不會自動變成 union。最後型別全部手寫在 stores/story.ts,JSON 只當測試 fixture。Claude 第一版用 any 混過去,被 frontend/CLAUDE.md「禁止 any」的規則擋下,第二版才改手寫型別——專案記憶的價值就在這種它「本來會偷懶」的時刻。
回頭打開 Claude Design 的 dialogue.html 對稿。這次的 Prompt 比 Day 7 更短,因為意圖說明已經在 README 裡:
Prompt(給 Claude Code)
「延續components/DialogueBox.vue(Day 7 產出)。加入:propsspeaker/text、打字機效果(預設 28 字/秒)、點一下時『打字中→直接顯示全文,已顯示完→emit next』、切換text時重新開始、unmount 時清 timer。樣式維持docs/design/README.md的 token。」
// frontend/src/components/DialogueBox.vue(script 節錄)
function startTyping(): void {
stopTyping()
shown.value = ''
const chars = Array.from(props.text) // 用 Array.from 才不會切壞中文/emoji
let i = 0
timer = window.setInterval(() => {
shown.value += chars[i++] ?? ''
if (i >= chars.length) stopTyping()
}, 1000 / (props.charsPerSec ?? 28))
}
function onTap(): void { // 還在打字 → 直接顯示全文;已顯示完 → 進下一句
if (timer !== null) { stopTyping(); shown.value = props.text }
else emit('next')
}
三個細節都是 Claude 一次寫對的:Array.from 而不是 split('')(代理對字元會被切壞,劇情裡的「……」不會,但加 emoji 會);watch(..., { immediate: true }) 讓第一句也走打字機;onUnmounted 清 timer,否則導頁後對白還在背景「打字」。28 字/秒試了 20、28、36 後選的:20 太慢想跳過,36 快到失去節奏感。
和設計稿比對,CSS 只差兩處——頭像手機版從 72px 縮到 48px、字級改成 clamp(14px, 2.2vw, 18px)——都是 RWD 調整,非設計偏差。Day 7 結論再次成立:意圖說明比像素重要。README 那句「對話框永遠貼底、不遮住戰場上半部」,比任何 px 值都更影響最後程式碼。
ChoiceList.vue 的 template 只有一個 v-for:每個選項一顆 <button>,:disabled="!story.canChoose(c) || story.loading",點擊 emit('choose', i);有 requires 的選項多一行小字「需要薪火 ×N」。Day 4 的 requires: { ember: N } 在此兌現:不夠就灰掉,但選項仍顯示——讓玩家知道「有條路現在走不了」,比藏起來更有敘事張力。story.loading 一起灰掉是防連點:後端對重複 choose 會回 409,前端先擋一層是禮貌。
StoryView.vue 只做兩件事:template 用 v-if="story.node?.type === 'dialogue'" 顯示 DialogueBox(@next="story.advance()"),v-else-if 是 choice 時顯示 ChoiceList(@choose="story.choose"),兩者互斥;script 用 watch 盯 story.node:battle 就 router.push 到 /battle/:levelId、ending 就去 /ending,{ immediate: true } 讓從戰鬥頁回來也立刻判斷。EndingView.vue 讀 node.ending 對照三個結局名:黎明、灰燼、薪火相傳,並把 flags 三個數字印出來——Day 25 QA 時就是斷言對象。

今天只放兩條規則,深水區留給明天:所有可點的東西 min-height: 44px(Apple 的建議,Android 也適用);< 768px 時對話框改兩欄 48px 頭像。ChoiceList 用 display: grid; place-content: center,手機橫向三個選項剛好塞進 375px 高度,不用捲動。對話框則固定貼底、最小高度 108px,手機版縮到 88px——這兩個數字來自設計稿,不是我猜的。今天在 iPhone 上只確認「看得到、點得到」,真正深水區(縮放、手勢、安全區)明天整篇談。
今天沒有新的引擎邏輯,全部是「把契約接起來」。最值得記的是我差點違反契約的那一刻:省一次往返聽起來像優化,其實是在前端偷偷養第二份狀態。Claude Code 的一句反問把我拉回來,因為 docs/api.md 與 backend/CLAUDE.md 都在它的 context 裡——契約寫下來,AI 才有東西反駁你。
從首頁輸入名字到看見「黎明結局」四個字,我用 Day 4 縮短版的劇情圖走了一遍:2 段對白、1 個抉擇、1 場戰鬥、1 個結局,全程沒有重新整理。第一次看到艾瑟琳的台詞一個字一個字打出來,我停下來看了三遍。
stores/story.ts(start / advance / choose / reportBattle)、api/client.ts(基本版)components/{DialogueBox,ChoiceList}.vue(DialogueBox 延續 Day 7)views/{HomeView,StoryView,EndingView}.vue、router/index.ts
Day 19:RWD 深水區:讓塔防遊戲在手機瀏覽器也能操作自如的斷點策略——今天在 iPhone 上打開戰鬥頁,畫布跑出螢幕,塔位點不到。